iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0
Python

Python 錦囊密技系列 第 18

【Python錦囊㊙️技18】單元測試(Unit Testing)進階篇

  • 分享至 

  • xImage
  •  

前言

【上一篇】簡單介紹Python內建測試模組unittest,這次我們會繼續討論更多關於單元測試的內容:

  1. 測試驅動開發(Test-Driven Development, TDD)
  2. Mocking。
  3. 覆蓋率分析(Coverage Analysis)。
  4. Django 測試方法。
  5. 【pytest】 套件。

測試驅動開發(Test-Driven Development, TDD)

TDD為了強調測試的重要性,喊出【先訂定測試案例,再撰寫程式】(Write test cases first),仔細研究,應該是【一邊訂定測試案例,再一邊撰寫程式】,依據【維基百科】的定義,TDD編程的循環(Coding cycle)如下圖:
https://ithelp.ithome.com.tw/upload/images/20241002/200019762g9hoXaMfB.png

  1. 依照程式規格書準備【少許】測試案例。
  2. 只撰寫符合測試案例的程式(Write only enough code)。
  3. 進行單元測試,如果有失敗案例,即修改程式,直到全部案例成功為止。
  4. 再逐步增加測試案例,回步驟2,修改程式,循環作業至測試案例完備為止。
  5. 由於測試案例增加,可能會發現程式需要大改,即重構(Refactoring),完成之後,再測試全部案例,即迴歸測試(Regrssion testing)。

這樣的作法優點是,不會寫了一堆code之後,發現太多的錯誤,挫折感大增,相對的,以TDD approach進行,類似RUP、動態規劃,將問題分而治之(Divide and conquer),拆分為小問題,逐個解決,最後就能順利解決大問題,筆者在程式教學過程中,利用此一方式效果非常好,例如要實作【9x9乘法表列印】,請學生依序完成下列任務:

  1. 先完成一組數字相乘,例如顯示4x5=20,解法如下:
i=4
j=5
print(f'{i}x{j}={i*j}')
  1. 進一步顯示一排字相乘。
i=4
for j in range(1,10):
    print(f'{i}x{j}={i*j}', end='  ')
  1. 顯示9x9乘法表。
for i in range(1,10):
    for j in range(1,10):
        print(f'{i}x{j}={i*j}', end='  ')
    print()    
  1. 美化輸出。
for i in range(1,10):
    for j in range(1,10):
        print(f'{i}x{j} = {i*j:2d}', end='  ')
    print()

以上程式儲存為tdd_tutorial.ipynb。

這種方式確實能幫助初學者無痛的學會基礎程式設計,就TDD而言,可以在每個步驟前,先訂定測試案例,每個步驟後進行單元測試,這種過程對於老手開發簡單的系統可能會覺得很無聊,但對於中大型的系統開發,經實證會比完全不進行單元測試的開發時程來的短,品質提升更不在話下。

Mocking

Mocking可以在測試環境中模擬真實的物件,假設有一支程式處理出貨程序,貨號是先經過條碼掃描機取得的,但測試環境中可能沒有條碼掃描系統,這時就可以使用Mocking取代。

Python內建模組直接支援Mocking,主要是取代物件,另外,Mock object也支援任意增加方法及屬性,以取代任何型態的物件,以下就以實作代替說明。

範例1. Mock簡單測試,程式修改自【Understanding the Python Mock Object Library】,程式名稱為18\mock_test.py。

  1. 引用Mock。
import unittest
from unittest.mock import Mock
  1. 以Mock取代原本的類別requests。
import requests
from requests.exceptions import Timeout

# 以 Mock 取代原本的 requests
requests = Mock()
  1. 受測的函數:會呼叫API,測試環境並不提供此API。
def get_holidays():
    r = requests.get("http://localhost/api/holidays")
    if r.status_code == 200:
        return r.json()
    return None
  1. 測試:
class TestHolidays(unittest.TestCase):
    def test_get_holidays_timeout(self):
        # 測試網路連線
        print(get_holidays())

if __name__ == "__main__":
    unittest.main()
  1. 執行結果:不會發生錯誤,因為Mock可生成任何方法,但不會如真實的requests,傳回狀態碼(status code)或回傳結果。
None
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
  1. Side effect:可以指定Mock方法的side_effect為一個任何資料型別、陣列、函數或例外,呼叫Mock方法就會得到設定的結果。修改TestHolidays如下,程式另存為18\mock_test2.py。
class TestHolidays(unittest.TestCase):
    def test_get_holidays_timeout(self):
        # 設定呼叫 requests.get 時會發生 Timeout 例外
        requests.get.side_effect = Timeout 
        # 若發生 Timeout 例外,執行縮排程式碼
        with self.assertRaises(Timeout):
            print('call get_holidays')
            print(get_holidays())
  1. 執行結果:設定呼叫requests.get會發生Timeout例外,因此get_holidays內第1列程式就會發生Timeout錯誤,提早結束,因此不會顯示None。
call get_holidays
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK

範例2. Mock的side_effect為一個任何資料型別、陣列、函數或例外,進行以下測試,程式修改自【官方unittest.mock文件】,程式名稱為18\mock_test3.py。

  1. 將side_effect 分別設為函數、list。
from unittest.mock import Mock

values = {'a': 1, 'b': 2, 'c': 3}
def side_effect(arg):
    return values[arg]

mock = Mock()

# side_effect 設為函數
mock.side_effect = side_effect
print(mock('a'), mock('b'), mock('c'))

# side_effect 設為list
mock.side_effect = [5, 4, 3, 2, 1]
print(mock(), mock(), mock())
  1. 執行結果:
1 2 3
5 4 3

範例3. Mock的回傳值(return_value)也可以指定,模擬呼叫結果,程式名稱為18\mock_test4.py。

  1. 修改18\mock_test3.py,將回傳值(return_value)設為Response物件,模擬HTTP API回傳值。
@dataclass
class Response:
    status_code:int
    json:str
    
# 以 Mock 取代原本的 requests
requests = Mock()
requests.get.return_value = Response(status_code=200, json={'data':'wondful'})
  1. 執行結果:會回傳設定的資料,更貼近真實的狀況,也可以模擬其他錯誤的情境。
{'data': 'wondful'}
.
----------------------------------------------------------------------
Ran 1 test in 0.002s
OK

另外,MagicMock支援物件魔術方法(Magic method)的設定,即【_】開頭的內建方法,還支援patch decorator,被指定的物件在測試期間會被替換為 mock,並在測試結束後恢復原狀,可參閱【官方unittest.mock文件】

覆蓋率分析(Coverage Analysis)

覆蓋率分析(Coverage Analysis)是測試的完整性的衡量指標(Metric),計算測試案例佔所有程式所有分支及可能值的比例,是品質保證的重要指標。

Coverage.py可支援Python覆蓋率分析的套件,安裝指令如下:

pip install coverage

範例4. 覆蓋率分析測試,複製\17\project2至18\project2。

  1. 切換至18\project2\tests資料夾,執行下列指令:
coverage run -m unittest discover
  1. 執行結果:與【python -m unittest discover】執行結果一樣,如下,但會額外產生一個檔案【.coverage】。
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
  1. 執行下列指令,顯示覆蓋率分析:
coverage report -m
  1. 執行結果:
  • __init__.py含3列程式碼,覆蓋率=100%。
  • test2.py含8列程式碼,TestMath函數1列未測試,故覆蓋率=88%。
    https://ithelp.ithome.com.tw/upload/images/20241002/20001976OROll2IvMQ.png
  1. 執行下列指令,生成網頁報表:
coverage html
  1. 為加強覆蓋率測試,我們再加1個函數至__init__.py。
def add_or_multiply(operator):
    if operator == '*':
        return 5 * 6
    else:
        return 5 + 6
  1. 重覆執行下列指令:
coverage run -m unittest discover
coverage report -m
coverage html
  1. 執行結果:可以看到檔案、函數及類別3個層次的說明。以【函數】頁籤為例:
  • __init__.py含2個函數,有各自的覆蓋率計算,因add_or_multiply函數未測試,故覆蓋率=0%。
  • test2.py也是如此。
    https://ithelp.ithome.com.tw/upload/images/20241002/2000197677pqUWm2Ev.png
  1. __init__.py加1個函數,
def add_or_multiply(operator):
    if operator == '*':
        return 5 * 6
    else:
        return 5 + 6
  1. test2.py加1個測試案例。
def test_add_or_multiply(self):
    self.assertEqual(add_or_multiply('*'), 5*6)
  1. 重覆執行下列指令:
coverage run -m unittest discover
coverage report -m
coverage html
  1. 執行結果:可以看到add_or_multiply函數覆蓋率=67%,因【if/else】只算一行指令,【else】分支未測試。
    https://ithelp.ithome.com.tw/upload/images/20241002/2000197679K22ziekq.png

coverage.py的指令及功能非常多,可參閱【Command line usage】,筆者非常喜歡這個套件。

Django Test Runner

【Python錦囊㊙️技16】展示Django網頁程式完整範例,如果要進行單元測試,Django提供內建測試框架,可參閱【Writing your first Django app, part 5】【Writing and running tests】,非常容易測試【視圖】(View),它是商業邏輯實作的主要程式碼,程式如下:

  1. 切換至16\mysite目錄。

  2. 執行下列指令:

python manage.py shell
  1. 建立測試環境:可以讓我們檢視回傳值(response.context)。
from django.test.utils import setup_test_environment
setup_test_environment()
  1. 建立用戶端,模擬瀏覽器:
from django.test import Client
client = Client()
  1. 以用戶端呼叫首頁,取得回應代碼,得到200,表正常。
response = client.get("/")
response.status_code
  1. 取得回應內容。
response.content
  1. 執行結果:得到一堆亂碼,因為內碼是UTF-8,而cmd預設內碼是Big5。
    https://ithelp.ithome.com.tw/upload/images/20241002/20001976moa6AhpGOl.png

  2. 可檢視model內容,例如:

response.context["poll_list"]
response.context["poll_list"][0]
response.context["poll_list"][0].pub_date

如果要測試post,可使用client.post(url, data),非常方便。

pytest

pytest是一個獨立的套件,與unittest比較,有以下優點:

  1. 直接使用assert指令取代下表。
  2. pytest認定test_開頭的檔名、類別及方法都是測試案例。
  3. 可直接使用tests目錄,與專案目錄平行。
  4. 可直接執行最後一個失敗的測試案例。
  5. 有上百擴充套件(Plugins)可增進pytest功能。

pytest實作

先安裝套件:

pip install pytest

範例5. 簡單測試,程式名稱為18\test_sample.py,注意,必須為test_開頭的檔名。

  1. 引用pytest。
import pytest
  1. 測試案例:不建立類別,不需繼承,只認test_開頭。
import pytest

# 測試函數
def func(x):
    return x + 1

# 測試案例
def test_answer():
    assert func(3) == 5
  1. 執行測試:直接輸入pytest,而非python test_sample.py。
pytest
  1. 執行結果:會得到很詳細的錯誤訊息。
================================================ test session starts =================================================
platform win32 -- Python 3.12.6, pytest-8.3.3, pluggy-1.5.0
rootdir: F:\0_python\00_MY\0_ITHome\src\18
plugins: anyio-4.6.0
collected 1 item

test_sample.py F                                                                                                [100%]

====================================================== FAILURES ======================================================
____________________________________________________ test_answer _____________________________________________________

    def test_answer():
>       assert func(3) == 5
E       assert 4 == 5
E        +  where 4 = func(3)

test_sample.py:9: AssertionError
============================================== short test summary info ===============================================
FAILED test_sample.py::test_answer - assert 4 == 5
================================================= 1 failed in 0.95s ==================================================

筆者初步測試結果並不順暢,pytest與unittest比較,並無明顯優勢,直接以test_開頭的辨識規則,不如unittest以繼承方式來的嚴謹,雖然,pytest有許多擴充套件,個人還是會採用unittest,因此,刪除部份內容說明,避免篇幅過長。

結語

以上只介紹單元測試,不要忘了還有系統整合測試(System Integration Testing, SIT)、使用者驗收測試(User Acceptance Testing, UAT),除了正確性,我們還要考慮壓力測試(Stress testing)、UI/UX,確保系統可承受尖峰的負載以及使用的順暢性,筆者曾經執行基金專案,原本系統很穩定,沒想到系統運作第一天,客戶搬來好幾台類似電腦閱卷的OCR scanner,瞬間輸入大量交易,馬上就把系統灌爆了,真是人算不如天算。

下一篇,我們來討論大型語言模型ChatGPT如何提升開發團隊的生產力。

本系列的程式碼會統一放在GitHub,本篇的程式放在src/18資料夾,歡迎讀者下載測試,如有錯誤或疏漏,請不吝指正。


上一篇
【Python錦囊㊙️技17】單元測試(Unit Testing)入門
下一篇
【Python錦囊㊙️技19】ChatGPT如何提升開發團隊的生產力(1)
系列文
Python 錦囊密技30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言